Micron Document
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
| SparkN0de-git | SparkN0de |
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------


Commit 38a09762fc1055009a7f517053ffd04e10f180dc


Parents : 67e78aa
Author : Ivan <e46112d44649266d71fe2193e00a4710>
Signature : T66BB85Valid, signed by author
Date : 2026-07-19T06:09:08-05:00

feat: improved Relay Chat with clickable Nomad and LXMF links, and support for basic markdown formatting in messages

Changes
Diff

diff --git a/CHANGELOG.md b/CHANGELOG.md
index b6c4bd5f..8eeb4410 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -30,6 +30,7 @@ All notable changes to this project will be documented in this file.
- Messages page conversation poll is slower and skips while the tab is hidden
- Schema v51 message-flag backfill skips empty databases so fresh init stays fast
- Relay Chat: denser hub UI, announce interval, collapsed system lines, reconnect notices
+- Relay Chat: clickable Nomad and LXMF links plus basic markdown for code, bold, italic, and strikethrough
- Low-memory cleanup and SQLite pragmas under memory pressure
- CI benches use median-of-medians and quieter regression gates
- Backend benchmarks cover slim conversation list, mark-as-read, call history, and missed-call notification paths

diff --git a/meshchatx.rsm b/meshchatx.rsm
index fae81540..56d027e5 100644
Binary files a/meshchatx.rsm and b/meshchatx.rsm differ

diff --git a/meshchatx/src/frontend/components/relay/RelayChatPage.vue b/meshchatx/src/frontend/components/relay/RelayChatPage.vue
index ace64525..e161be62 100644
--- a/meshchatx/src/frontend/components/relay/RelayChatPage.vue
+++ b/meshchatx/src/frontend/components/relay/RelayChatPage.vue
@@ -1325,6 +1325,8 @@ import { loadRelayLayout, saveRelayLayout } from "../../js/relayLayoutStore.js";
import { loadFeatureSidebarCollapsed, saveFeatureSidebarCollapsed } from "../../js/browserLayoutStore.js";
import { RELAY_HOST_MODAL_OVERLAY, RELAY_HOST_MODAL_PANEL_COMPACT } from "../../js/relayHostModalClasses.js";
import { buildRelayShareMessage } from "../../js/relayLinkUtils.js";
+import { handleRichHtmlLinkClick } from "../../js/NomadRichHtmlLinks.js";
+import MarkdownRenderer from "../../js/MarkdownRenderer.js";
import {
ANNOUNCE_SLIDER_POS_MAX,
announceMinutesToSliderPos,
@@ -2407,6 +2409,33 @@ export default {
return "";
}
},
+ renderMessageHtml(text) {
+ return MarkdownRenderer.renderBasic(text);
+ },
+ handleMessageHtmlClick(event) {
+ const hex32 = /^[a-fA-F0-9]{32}$/;
+ const routeName = this.$route?.meta?.isPopout ? "nomadnetwork-popout" : "nomadnetwork";
+ handleRichHtmlLinkClick(event, {
+ onNomadUrl: (url) => {
+ const [hash, ...pathParts] = url.split(":");
+ const path = pathParts.join(":");
+ if (!hex32.test(hash)) {
+ return;
+ }
+ this.$router.push({
+ name: routeName,
+ params: { destinationHash: hash },
+ query: { path },
+ });
+ },
+ onLxmfAddress: (address) => {
+ this.$router.push({
+ name: "messages",
+ params: { destinationHash: address },
+ });
+ },
+ });
+ },
formatHash(hash) {
if (!hash) {
return "-";

diff --git a/meshchatx/src/frontend/components/relay/RelayMessageEntry.vue b/meshchatx/src/frontend/components/relay/RelayMessageEntry.vue
index 696c8603..9c9b658a 100644
--- a/meshchatx/src/frontend/components/relay/RelayMessageEntry.vue
+++ b/meshchatx/src/frontend/components/relay/RelayMessageEntry.vue
@@ -54,7 +54,12 @@
:data-msg-key="page.messageKey(entry.msg)"
>
<span class="mr-1 text-xs text-sem-fg-muted">{{ page.formatTime(entry.msg.ts) }}</span>
- * {{ page.displayName(entry.msg) }} {{ entry.msg.text }}
+ * {{ page.displayName(entry.msg) }}
+ <span
+ class="break-words"
+ v-html="page.renderMessageHtml(entry.msg.text)"
+ @click="page.handleMessageHtmlClick($event)"
+ ></span>
</div>
<div
v-else-if="entry.msg"
@@ -65,7 +70,11 @@
>
<span class="mr-1.5 text-xs text-sem-fg-muted">{{ page.formatTime(entry.msg.ts) }}</span>
<span class="mr-1.5 font-semibold" :style="page.nameStyle(entry.msg)">{{ page.displayName(entry.msg) }}:</span>
- <span class="whitespace-pre-wrap break-words">{{ entry.msg.text }}</span>
+ <span
+ class="whitespace-pre-wrap break-words"
+ v-html="page.renderMessageHtml(entry.msg.text)"
+ @click="page.handleMessageHtmlClick($event)"
+ ></span>
</div>
</template>

diff --git a/meshchatx/src/frontend/js/MarkdownRenderer.js b/meshchatx/src/frontend/js/MarkdownRenderer.js
index 485292ad..943a753e 100644
--- a/meshchatx/src/frontend/js/MarkdownRenderer.js
+++ b/meshchatx/src/frontend/js/MarkdownRenderer.js
@@ -85,6 +85,48 @@ export default class MarkdownRenderer {
return processed_parts.join("\n");
}
+ /**
+ * Minimal markdown for chat surfaces (RRC): links, inline code, bold, italic, strikethrough.
+ * Links run before inline code so Nomad field data after a single backtick stays intact.
+ */
+ static renderBasic(text) {
+ if (text == null) {
+ return "";
+ }
+ if (typeof text !== "string") {
+ text = String(text);
+ }
+
+ text = Utils.escapeHtml(text);
+ text = LinkUtils.renderAllLinks(text);
+
+ const { protectedText, anchors } = LinkUtils.protectAnchors(text);
+ text = protectedText;
+
+ const inline_codes = [];
+ const pushInline = (code) => {
+ const placeholder = `[[IC${inline_codes.length}]]`;
+ inline_codes.push(
+ `<code class="bg-black/10 dark:bg-white/10 px-1 rounded-sm font-mono text-[0.9em]">${code}</code>`
+ );
+ return placeholder;
+ };
+ text = text.replace(/``([^`]+)``/g, (_m, code) => pushInline(code));
+ text = text.replace(/`([^`]+)`/g, (_m, code) => pushInline(code));
+
+ text = text.replace(/~~(.*?)~~/g, "<del>$1</del>");
+ text = text.replace(/\*\*\*(.*?)\*\*\*/g, "<strong><em>$1</em></strong>");
+ text = text.replace(/\*\*(.*?)\*\*/g, "<strong>$1</strong>");
+ text = text.replace(/\*(.*?)\*/g, "<em>$1</em>");
+ text = text.replace(/(^|[^\w])___(.*?)___(?=[^\w]|$)/g, "$1<strong><em>$2</em></strong>");
+ text = text.replace(/(^|[^\w])__(.*?)__(?=[^\w]|$)/g, "$1<strong>$2</strong>");
+ text = text.replace(/(^|[^\w])_(.*?)_(?=[^\w]|$)/g, "$1<em>$2</em>");
+
+ text = text.replace(/\[\[IC(\d+)\]\]/g, (_m, idx) => inline_codes[Number(idx)] ?? _m);
+ text = LinkUtils.restoreAnchors(text, anchors);
+ return text.replace(/\n/g, "<br>");
+ }
+
/**
* True when the body is only a single emoji (after markdown strip), for large bubble rendering.
*/
@@ -131,7 +173,8 @@ export default class MarkdownRenderer {
// Strip headers
text = text.replace(/^#+ (.*)$/gm, "$1");
- // Strip bold and italic
+ // Strip strikethrough, bold and italic
+ text = text.replace(/~~(.*?)~~/g, "$1");
text = text.replace(/\*\*\*(.*?)\*\*\*/g, "$1");
text = text.replace(/\*\*(.*?)\*\*/g, "$1");
text = text.replace(/\*(.*?)\*/g, "$1");

diff --git a/tests/frontend/MarkdownRenderer.test.js b/tests/frontend/MarkdownRenderer.test.js
index da3dce02..71f8333a 100644
--- a/tests/frontend/MarkdownRenderer.test.js
+++ b/tests/frontend/MarkdownRenderer.test.js
@@ -527,3 +527,44 @@ describe("MarkdownRenderer.js", () => {
});
});
});
+
+describe("renderBasic (RRC / limited chat markdown)", () => {
+ it("renders bold, italic, strikethrough, and inline code", () => {
+ const result = MarkdownRenderer.renderBasic("**bold** *italic* ~~gone~~ `code`");
+ expect(result).toContain("<strong>bold</strong>");
+ expect(result).toContain("<em>italic</em>");
+ expect(result).toContain("<del>gone</del>");
+ expect(result).toContain("<code");
+ expect(result).toContain("code");
+ });
+
+ it("does not render headers or blockquotes", () => {
+ const result = MarkdownRenderer.renderBasic("# Title\n> quote");
+ expect(result).not.toContain("<h1");
+ expect(result).not.toContain("blockquote");
+ expect(result).toContain("# Title");
+ });
+
+ it("keeps Nomad path field data after a single backtick as one link", () => {
+ const text =
+ "9ce92808be498e9e05590ff27cbfdfe4:/page/forum/thread.mu`cat=general|thread=critical-security-update-rns-139-fixes-severe-rnsh-security-flaw";
+ const result = MarkdownRenderer.renderBasic(text);
+ expect(result).toContain("nomadnet-link");
+ expect(result).toContain(
+ 'data-nomadnet-url="9ce92808be498e9e05590ff27cbfdfe4:/page/forum/thread.mu`cat=general|thread=critical-security-update-rns-139-fixes-severe-rnsh-security-flaw"'
+ );
+ expect(result).not.toContain("<code");
+ });
+
+ it("renders https links and escapes HTML", () => {
+ const result = MarkdownRenderer.renderBasic('hi <script>alert(1)</script> https://example.com/a');
+ expect(result).not.toContain("<script>");
+ expect(result).toContain("&lt;script&gt;");
+ expect(result).toContain('href="https://example.com/a"');
+ });
+
+ it("preserves line breaks as br tags", () => {
+ const result = MarkdownRenderer.renderBasic("line1\nline2");
+ expect(result).toBe("line1<br>line2");
+ });
+});


──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────